ObsidianのLocal Graphのcode reading
code:ts
/// <reference no-default-lib="true" />
/// <reference lib="esnext" />
/// <reference lib="dom" />
import {
select,
selectAll,
import {
forceCenter,
forceLink,
forceManyBody,
forceSimulation,
Simulation,
SimulationLinkDatum,
SimulationNodeDatum,
入力するデータの定義
code:ts
interface Node extends SimulationNodeDatum {
/** ページタイトル */
id: string;
}
type Link = SimulationLinkDatum<Node>;
declare const data: {
nodes: Node[];
links: Link[];
};
SVG領域の作成
code:ts
declare const scale: number;
declare const isHome: boolean;
const container = document.getElementById("graph-container")!;
const height = Math.max(container.offsetHeight, isHome ? 500 : 250);
const width = container.offsetWidth;
const svg = select(container)
.append("svg")
.attr("width", width)
.attr("height", height)
.attr(
"viewBox",
//@ts-ignore githubで閲覧すると、配列も指定できるようになっている
[
-width / 2 * 1 / scale,
-height / 2 * 1 / scale,
width * 1 / scale,
height * 1 / scale,
],
);
pathの色を決める
code:ts
declare const pathColors: Record<string, string>[];
declare const curPage: string;
const color = (d: Node) => {
if (d.id === curPage || (d.id === "/" && curPage === "")) {
return "var(--g-node-active)";
}
for (const pathColor of pathColors) {
const path = Object.keys(pathColor)0; const colour = pathColorpath; if (d.id.startsWith(path)) {
return colour;
}
}
return "var(--g-node)";
};
legend
code:ts
declare const enableLegend: boolean;
if (enableLegend) {
const legend = [{ Current: "var(--g-node-active)" }, {
Note: "var(--g-node)",
}, ...pathColors];
legend.forEach((legendEntry, i) => {
const key = Object.keys(legendEntry)0; const colour = legendEntrykey; svg
.append("circle")
.attr("cx", -width / 2 + 20)
.attr("cy", height / 2 - 30 * (i + 1))
.attr("r", 6)
.style("fill", colour);
svg
.append("text")
.attr("x", -width / 2 + 40)
.attr("y", height / 2 - 30 * (i + 1))
.text(key)
.style("font-size", "15px")
.attr("alignment-baseline", "middle");
});
}
Linkの作成
code:ts
// draw links between nodes
const link = svg
.append("g")
.selectAll("line")
.data(data.links)
.join("line")
.attr("class", "link")
.attr("stroke", "var(--g-link)")
.attr("stroke-width", 2)
// After initialization, d.source and d.target are converted to Node
.attr("data-source", (d) => (d.source as Node).id)
.attr("data-target", (d) => (d.target as Node).id);
Nodeの大きさを決める
半径=$ 2+\sqrt{N_{in}+N_{out}}
意味がよく読み取れない式だtakker.icon
まあリンク数が多いほど大きくなることはわかる
code:ts
declare const index: {
links: Record<string, Node[]>;
backlinks: Record<string, Node[]>;
};
// calculate radius
const nodeRadius = (d: Node) => {
const numOut = index.linksd.id?.length ?? 0; const numIn = index.backlinksd.id?.length ?? 0; return 2 + Math.sqrt(numOut + numIn);
};
code:ts
declare const repelForce: number;
const simulation = forceSimulation<Node, Link>(data.nodes)
.force(
"link",
forceLink<Node, Link>(data.links)
.id((d) => d.id)
.distance(50),
)
.force("charge", forceManyBody().strength(-100 * repelForce))
.force("center", forceCenter());
code:ts
declare const enableDrag: boolean;
const drag_ = <T extends Element>() => {
const noop = () => {};
type DEvent = D3DragEvent<T, Node, SVGGElement>;
return enableDrag
? drag<T, Node>()
.on(
"start",
(event: DEvent, d) => {
if (!event.active) simulation.alphaTarget(1).restart();
d.fx = d.x;
d.fy = d.y;
},
)
.on("drag", (event: DEvent, d) => {
d.fx = event.x;
d.fy = event.y;
})
.on("end", (event: DEvent, d) => {
if (!event.active) simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
})
: drag<T, Node>().on("start", noop).on("drag", noop).on(
"end",
noop,
);
};
code:ts
// svg groups
const graphNode = svg.append("g").selectAll("g").data(data.nodes).enter()
.append("g");
Nodeの作成
関連ノードのみをtransitionする必要があるから無理
CSSを工夫すれば行けるかも?
:hover ~ [data-source="..."]で指定するとか
Nodeの数だけ動的にCSSを生成する必要があるな
これはDenoでは認識できない
code:ts
declare const content: Record<
string,
{
content: string;
title: string;
lastModified: string;
tags: string[] | null;
}
;
declare const fontSize: number;
// draw individual nodes
const node = graphNode
.append("circle")
.attr("class", "node")
.attr("id", (d) => d.id)
.attr("r", nodeRadius)
.attr("fill", color)
.style("cursor", "pointer")
.on("click", (_, d) => {
// SPA navigation
// Only navigate when the page exists
window.open(
new URL(${location.hostname}${decodeURI(d.id).replace(/\s+/g, "-")}/),
".singlePage",
);
}
})
.on("mouseover", (e: MouseEvent, d) => {
const nodes = selectAll<SVGCircleElement, Node>(
".node",
) as unknown as Transition<
SVGCircleElement,
Node,
SVGGElement,
unknown
;
nodes.transition().duration(100).attr(
"fill",
"var(--g-node-inactive)",
);
const neighbours: string[] = [];
const neighbourNodes = selectAll<SVGCircleElement, Node>(".node").filter((
d,
) => neighbours.includes(d.id)) as unknown as Transition<
SVGCircleElement,
Node,
SVGGElement,
unknown
;
const currentId = d.id;
window.open(
new URL(${location.hostname}${decodeURI(d.id).replace(/\s+/g, "-")}/),
);
const linkNodes = selectAll<SVGLineElement, Link>(".link")
.filter((d) =>
(d.source as Node).id === currentId ||
(d.target as Node).id === currentId
) as unknown as Transition<
SVGLineElement,
Link,
SVGGElement,
unknown
;
// highlight neighbour nodes
neighbourNodes.transition().duration(200).attr("fill", color);
// highlight links
linkNodes.transition().duration(200).attr(
"stroke",
"var(--g-link-active)",
);
const bigFont = fontSize * 1.5;
// show text for self
const self = e.target as SVGCircleElement;
const parent = self.parentNode as SVGGElement;
const label = select(parent)
.raise()
.select("text") as unknown as Transition<
SVGTextElement,
Node,
SVGGElement,
unknown
;
label.transition()
.duration(200)
.attr(
"opacityOld",
select(parent).select("text").style("opacity"),
)
.style("opacity", 1)
.style("font-size", ${bigFont}em)
.attr("dy", (d) => ${nodeRadius(d) + 20}px); // radius is in px
})
.on("mouseleave", (e: MouseEvent, d) => {
const nodes = selectAll<SVGCircleElement, Node>(
".node",
) as unknown as Transition<
SVGCircleElement,
Node,
SVGGElement,
unknown
;
nodes.transition().duration(200).attr("fill", color);
const currentId = d.id;
const linkNodes = selectAll<SVGLineElement, Link>(".link")
.filter((d) =>
(d.source as Node).id === currentId ||
(d.target as Node).id === currentId
) as unknown as Transition<
SVGLineElement,
Link,
SVGGElement,
unknown
;
linkNodes.transition().duration(200).attr("stroke", "var(--g-link)");
const self = e.target as SVGCircleElement;
const parent = self.parentNode as SVGGElement;
const label = select(parent)
.raise()
.select("text") as unknown as Transition<
SVGTextElement,
Node,
SVGGElement,
unknown
;
label.transition()
.duration(200)
.style(
"opacity",
select(parent).select("text").attr("opacityOld"),
)
.style("font-size", ${fontSize}em)
.attr("dy", (d) => ${nodeRadius(d) + 8}px); // radius is in px
})
.call(drag_());
Labelの作成
code:ts
declare const opacityScale: number;
// draw labels
const labels = graphNode
.append("text")
.attr("dx", 0)
.attr("dy", (d) => ${nodeRadius(d) + 8}px)
.attr("text-anchor", "middle")
.text((d) =>
contentd.id?.title || decodeURIComponent(d.id.replace("-", " ")) )
.style("opacity", (opacityScale - 1) / 3.75)
.style("pointer-events", "none")
.style("font-size", ${fontSize}em)
.raise()
.call(drag_());
zooming
code:ts
// set panning
declare const enableZoom: boolean;
if (enableZoom) {
svg.call(
zoom<SVGSVGElement, unknown>()
.extent([
])
.on("zoom", ({ transform }: D3ZoomEvent<SVGSVGElement, unknown>) => {
link.attr("transform", transform.toString());
node.attr("transform", transform.toString());
const scale = transform.k * opacityScale;
// 縮尺が小さいほど薄くなる
const scaledOpacity = Math.max((scale - 1) / 3.75, 0);
labels.attr("transform", transform.toString()).style(
"opacity",
scaledOpacity,
);
}),
);
}
描画の更新
code:ts
// progress the simulation
simulation.on("tick", () => {
link
.attr("x1", (d) => (d.source as Node).x ?? null)
.attr("y1", (d) => (d.source as Node).y ?? null)
.attr("x2", (d) => (d.target as Node).x ?? null)
.attr("y2", (d) => (d.target as Node).y ?? null);
node.attr("cx", (d) => d.x ?? null).attr("cy", (d) => d.y ?? null);
labels.attr("x", (d) => d.x ?? null).attr("y", (d) => d.y ?? null);
});